iT邦幫忙

2024 iThome 鐵人賽

DAY 15
2
Python

Django 忍法帖——Django Ninja 入門指南系列 第 15

卷 15:回應(三)為何不用 ModelSchema?——相比 DRF,我更偏愛 Django Ninja 的理由

  • 分享至 

  • xImage
  •  

Django API 回應,常常是對 Model 物件(即 db 資料)內容進行一定的篩選與加工

比如「取得單一文章資訊」API,實際上就是從Post物件挑選欄位,再進行序列化。

這個過程中,我們需要考慮如何將模型物件轉換為 API 的回應結構,同時保持程式碼的可維護性與靈活。

對此,Django REST framework(以下簡稱 DRF)提供了非常實用的「特製」序列化器——ModelSerializer,可說是 DRF 開發者必學的核心功能。

Django Ninja 雖然也有類似的實踐——ModelSchema,對我而言卻是雞肋般的存在,我幾乎不曾使用

這樣的差異,無疑是兩者的核心設計理念不同所導致。

我們曾在第 3 篇中討論過,兩者在功能上的主要區別。本文將透過「Django 模型物件的序列化」這個頗具代表性的議題,說明「為何相比於 DRF,我更喜歡寫 Django Ninja」。


ModelSerializer 的亮點

DRF 中的ModelSerializer是個非常強大的工具,它能夠自動將 Django 模型轉換為 API 需要的資料結構——序列化器,大大簡化了「為序列化器定義欄位」的過程。

附帶一提,DRF 序列化器,相當於 Django Ninja 所使用的 Schema,兩者的概念大同小異,都是用於資料的驗證與序列化

如果我們把「取得單一文章資訊」API 回應用ModelSerializer改寫,它將長這樣:

from rest_framework import serializers

# Author 序列化器
class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'email']

# Post 序列化器
class PostSerializer(serializers.ModelSerializer):
    author = AuthorSerializer()  # 嵌套的 Author 序列化器

    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'author', 'created_at', 'updated_at']

如你所見,透過ModelSerializer,我們只需要少少的程式碼便能定義完序列化器,從而避免了手動設定的重複與麻煩。


ModelSerializer 的隱憂

然而,這樣的方便也帶來一定的隱憂

因為不用自己定義欄位,所以ModelSerializer幫你做了許多欄位的隱式轉換——從 Django Model 欄位轉換為序列化器欄位。

為何說「隱式」呢?因為自動轉換後的序列化器欄位,其欄位的型別、特性、是否唯讀(read_only)等細節,你未必清楚

換言之,ModelSerializer不僅會自動生成欄位,還會自動推斷欄位的型別、屬性、屬性的參數等。

舉例說明

這樣講有點抽象,對於沒寫過 DRF 的讀者可能不好太理解。我們直接看一個例子:

from django.db import models

class Person(models.Model):
    first_name = models.CharField(max_length=30)
    last_name = models.CharField(max_length=30)

這是一個超簡單的 Django Model,我引用自 Django 官方文件

它有兩個欄位first_namelast_name,實際上它還有一個 Django 自動生成的id欄位,在程式碼中沒有顯示。

ModelSerializer 的「魔法」

使用ModelSerializer,我們可以這樣定義序列化器:

from rest_framework import serializers
from .models import Person

class PersonSerializer(serializers.ModelSerializer):
    class Meta:
        model = Person
        fields = ['id', 'first_name', 'last_name']

程式碼很簡單,但它背後的「魔法」卻很多。

具體而言,實際上的序列化器和欄位長這樣:

from rest_framework import serializers

class PersonSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    first_name = serializers.CharField(max_length=30)
    last_name = serializers.CharField(max_length=30)

    def create(self, validated_data):
        return Person.objects.create(**validated_data)

    def update(self, instance, validated_data):
        instance.first_name = validated_data.get('first_name', instance.first_name)
        instance.last_name = validated_data.get('last_name', instance.last_name)
        instance.save()
        return instance

有沒有覺得有點吃驚

隱式轉換了做很多事

其中,id欄位被自動加上了read_only=Truefirst_namelast_name則被自動加上了max_length=30

這還是在 Django Model 的設計與欄位參數相對簡單的情況下,當 Model 欄位更複雜時,ModelSerializer的「魔法」也會變得更加複雜。

它背後有很多轉換邏輯,讓開發者在某些情況下必須去理解這些「隱藏規則」——因為這個推斷有時可能不符合你的需求,導致你需要手動覆寫

總之,自動推斷與轉換固然省去了手動設定的麻煩,但當你需要調整某些細節,或理解具體的轉換邏輯時,這種隱式行為可能會讓你感到困惑

魔法的代價

在實際開發中,這種隱式轉換的「魔法」會讓開發者失去對轉換過程的理解與掌控。你很可能會發現,序列化的結果和你想的並不完全一致!

此時我們往往需要翻閱 DRF 的官方文件來理解內部如何處理這些欄位轉換,但也不是每個細節都寫得清楚明白。

對開發者而言,特別是在處理複雜 API 時,會明顯增加學習和維護成本。

以上正是我的經驗!

即使寫了 2 年 DRF,遇到序列化問題,我還是很常需要重新查看文件


ModelSchema

Django Ninja 的 ModelSchema 相較於 ModelSerializer,則顯得「陽春」許多。

怎麼說?我們看一下官方文件中的例示:

from django.contrib.auth.models import User
from ninja import ModelSchema

class UserSchema(ModelSchema):
    class Meta:
        model = User
        fields = ['id', 'username', 'first_name', 'last_name']

# Will create schema like this:
#
# class UserSchema(Schema):
#     id: int
#     username: str
#     first_name: str
#     last_name: str

說它陽春,因為它只會幫你自動轉換、定義欄位的「型別」而已。其他欄位細節,比如max_length,都要靠Field來設定——ModelSchema 不會幫你做這些。

而 DRF 的ModelSerializer,如前所述,則是會「做更多」。

既然 ModelSchema 的自動轉換相對單純,那為何我還是不建議使用呢?有兩個理由。

其中第一個理由,就是標題所說「為何我更偏愛 Django Ninja」的理由。


理由一:低耦合 + 明確優於隱晦

Django Ninja 更強調開發者對 API 結構的掌握,而 DRF 則偏向於提供高度整合且便利的工具。

這種差異反映在它們對待 Django 模型序列化的方式上,也影響了開發者在使用這兩個框架時的風格和思維方式

Django REST framework 和 Django 高度耦合

我們可以發現, DRF 幾乎是一個「為 Django 高度定製」的 API 開發工具。

這種緊密的結合雖然帶來了便利性,但也意味著 DRF 在很大程度上依賴於 Django 的內部結構和功能。不管是 Generic views,還是本文的 ModelSerializer,都是如此。

高耦合的優點就是你可以少做很多事,而代價則是你要很了解自己在做什麼

明確優於隱晦

相較於 DRF,Django Ninja 與 Django 的耦合程度則要低得多

在我看來,Django Ninja 更偏好「明確優於隱晦」,Django Ninja 的 Schema 定義是基於 Pydantic,它要求開發者明確定義每個欄位,無論是輸入還是輸出。

雖然這樣相對繁瑣,但它帶來的好處是顯而易見的。

明確的兩大優點

首先,手動定義 Schema 讓開發者對資料結構有著絕對的掌控權。沒有任何隱藏規則或暗箱操作,一切都清晰可見。

其次,這種方法有效地降低了模型層與 API 層之間的耦合。在實際開發中,模型設計可能會隨著需求變化而更新,但這不應該直接影響到 API。

總的來說,Django Ninja 強調以 Schema 為核心的控制,讓 API 的設計更具穩定性和靈活性,並賦予開發者對資料流的完全掌控。


理由二:更好、更可讀的 API 文件

在第 18 篇,我們會詳細討論 Schema 欄位設定對 API 文件的影響。

簡言之,如果使用 ModelSchema,那麼渲染出來的 API 文件將會相當陽春

並不符合我對 API 文件清晰與明確性的追求。


結語

不可否認,Django REST framework 有一些非常方便且貼心的設計,比如上一篇提到的source=參數,它直觀而優雅。

Django Ninja 則要求開發者,盡可能手動定義每個欄位,減少模型與 API 層的耦合,這更符合 Python 哲學中的「明確優於隱晦」,同時避免隱式行為帶來的潛在問題。

這正是我更偏愛 Django Ninja 的原因。

Django Ninja 對明確性的追求,讓我在開發和維護 API 時,多數時候感覺更加輕鬆

本文同步發表於我的部落格——Code and Me


上一篇
卷 14:回應(二)用 Schema 建立巢狀結構回應
下一篇
卷 16:回應(四)Resolver 方法——欄位資料格式化
系列文
Django 忍法帖——Django Ninja 入門指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
gbaian10
iT邦新手 5 級 ‧ 2024-09-28 03:44:19

DRF:

class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'email']

Ninja:

class UserSchema(ModelSchema):
    class Meta:
        model = User
        fields = ['id', 'username', 'first_name', 'last_name']

Django Ninja 的 ModelSchema 相較於 ModelSerializer,則顯得「單純」許多。

都沒用過的我,覺得兩個都長差不多?! 只知道繼承的東西不同

看更多先前的回應...收起先前的回應...
Kyo Huang iT邦新手 4 級 ‧ 2024-09-29 10:18:51 檢舉

你的問題一語驚醒夢中人!

我沒有發現文章中存在的這個問題(指兩者很像,看不出 ModelSchema 哪裡「相對單純」了),顯然我已經對 DRF 習以為常,造成創作上的盲點

經過一番修改,我在「ModelSerializer 的隱憂」中加上了「自動轉換的範圍」一整段,並在「ModelSchema」段落中多解釋了一點其中的「單純」所在

你再看看有沒有解惑到🥹

gbaian10 iT邦新手 5 級 ‧ 2024-09-29 14:31:26 檢舉
class AccountSerializer(serializers.ModelSerializer):
    class Meta:
        model = Account
        fields = ['id', 'account_name', 'users', 'created']

Model 中的name欄位,被ModelSerializer轉換為序列化器的name欄位時,自動被加上了allow_blank=True、max_length=100、required=False等屬性:

name 欄位是指 account_name ?


from django.contrib.auth.models import User
from ninja import ModelSchema

class UserSchema(ModelSchema):
    class Meta:
        model = User
        fields = ['id', 'username', 'first_name', 'last_name']

# Will create schema like this:
#
# class UserSchema(Schema):
#     id: int
#     username: str
#     first_name: str
#     last_name: str

下面註解部分 Schema 是 pydantic 模式我是懂的,看起來像是說明上面那段幾乎等於下面這段

但我好奇的是它(Django)是怎知道每個 field 的 type? 為什知道 id 就是 int 而不是 str,為什知道 username 是個 str 而不是其他 type?

還是說這些 type 都是 Runtime 時候才被確定?
如果是 Runtime 時候才確定那就可以明白這樣的寫法確實無法自動產出 API 文件(至少每個欄位的型別是無法預先知道的)


然後這樣一比也確實明白 DRF 的缺點,確實很大程度上違背了 Python之禪的第二段:
Explicit is better than implicit. (明瞭優於隱晦。)

DRF 背後做了太多事情,除非你明確知道,否則太多東西不夠明顯。就算你知道,你的同事也不一定知道,或者未來的你可能會忘記。

gbaian10 iT邦新手 5 級 ‧ 2024-09-29 14:35:58 檢舉

Type Hint 寫久了真的會很難適應沒寫 Type Hint 的程式碼,IDE 各種無法支援自動補全,讀 code 也很難過。

假設有公司找我,如果我知道他們公司內部程式碼都不寫 Type Hint,我一定不去

Kyo Huang iT邦新手 4 級 ‧ 2024-09-29 14:51:45 檢舉

name 欄位是指 account_name ?

name 就是 name XD,Account Model 有這個屬性,而且屬性上有一些欄位參數設定

只是官方文件似乎沒有為這個 Account Model 舉例(至少我找不到),直接就進序列化器

但我好奇的是它(Django)是怎知道每個 field 的 type? 為什知道 id 就是 int 而不是 str,為什知道 username 是個 str 而不是其他 type?

DRF 就是去參考 Django Model 中關於欄位的設定,再轉換成序列化器的格式參數,其中的隱性規則很多!

比如 Model 欄位中有null=True,它就會轉換成required=False、是 primary key 的話,就會加上required=Trueeditable=False,很多很多!

就是被這些隱性轉換規則搞得要反覆查閱文件,做了一堆筆記——但非常容易忘記😂

Kyo Huang iT邦新手 4 級 ‧ 2024-09-29 14:55:18 檢舉

Type Hint 寫久了真的會很難適應沒寫 Type Hint 的程式碼,IDE 各種無法支援自動補全,讀 code 也很難過。

我認同,習慣了 type hints,如果不寫,真的很為難,誰用誰知道!

假設有公司找我,如果我知道他們公司內部程式碼都不寫 Type Hint,我一定不去

灰常支持!哈哈哈

gbaian10 iT邦新手 5 級 ‧ 2024-09-29 16:02:43 檢舉

Account Model, Django Model

突然好多新詞,Account Model (還是AccountNameModel?),是根據 account_name 這個欄位名稱 背後動態生成的 一個 class 嗎? 且有一個自己的name屬性?
還是真的有一個專有名詞叫這個,跟欄位名稱無關

Django Model 欄位設定? 是還要去別的地方設定這些自定義 Field 的欄位名稱和型別嗎? 這樣聽起來好像還是在別邊要寫型別提示? 那感覺好像也沒少寫 code=口=

越來越亂了,好複雜XDD

既然 ModelSchema 的自動轉換相對單純,那為何我還是不建議使用

不過根據文中上面那段話,你最後應該選擇使用了類 pydantic 模式來明示這些 type。
我目前還是很喜歡 FastAPI 和 Ninja(?) 的 pydantic 模式,真的清楚許多!

上面那些隱式規則可能等我以後自己必須用到才去翻文件好了
希望一輩子不會用到
這種隱式規則太多的框架大概就是易學難精,簡單使用沒什問題,很輕鬆。
但當你需要應付各種高度客制化的需求,它可能反而會是個障礙,你得花更多時間來熟悉整個框架的用法與讀文件或看 source code才能理解,可能會花更多時間

Kyo Huang iT邦新手 4 級 ‧ 2024-09-29 16:29:57 檢舉

哈哈哈,你想得太複雜了——也可能是我沒有 get 到你的點。沒關係,我們 high level 地整理一下思路,不必涉及太多細節

Django 專案中,總要定義 ORM 的 Model(相當於 SQLAlchemy 的 Base),也就是我所謂的 Django Model,大概長這樣

from django.db import models

class Person(models.Model):
    # (還有一個 Django 自動加的 id 欄位,不會顯示)
    first_name = models.CharField(max_length=30)
    last_name = models.CharField(max_length=30)

可以看到,CharField 代表字元,相當於 table 欄位的 SQL schema,你能為每一個欄位設定一些參數,這裡只有很簡單的max_length=30設定,常見的還有null=Trueunique=True等等

然後,DRF 的 ModelSerializer 會去「閱讀」這些 Django Model 與每一個欄位定義(含參數),自動轉換成序列化器,上面的 Django Model 會被轉換成以下序列化器:

class PersonSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    first_name = serializers.CharField(max_length=30)
    last_name = serializers.CharField(max_length=30)

    def create(self, validated_data):
        return Person.objects.create(**validated_data)

    def update(self, instance, validated_data):
        instance.first_name = validated_data.get('first_name', instance.first_name)
        instance.last_name = validated_data.get('last_name', instance.last_name)
        instance.save()
        return instance

是不是幫你做了很多事?XD

但在實際 DRF 專案的程式碼中,你只會看到:

from rest_framework import serializers
from .models import Person

class PersonSerializer(serializers.ModelSerializer):
    class Meta:
        model = Person
        fields = ['id', 'first_name', 'last_name']

是不是隱藏了很多細節?XD

Kyo Huang iT邦新手 4 級 ‧ 2024-09-29 16:33:24 檢舉

gbaian10 笑了,我還真的把account_name誤植為name然後自己一直沒發現

結果,我又看了一下 DRF 的文件,他真的寫得不一樣,前面是account_name後面又是name,氣鼠偶

我決定要重寫這一段,用上面的例子來講就好了,可惡

gbaian10 iT邦新手 5 級 ‧ 2024-09-29 16:52:24 檢舉

該去DRF文件提 issue 了

我就想說 name 到底是不是指 account_name,還是什麼內部特殊屬性或什參數叫 name (還真不少套件有這樣)


有上面例子這樣看我就懂了
from .models import Person 和前面有寫了 Person 這段大概就知道它是另一個檔案寫好的,而這個 Person 裡面內容是對應資料庫的欄位寫的

但在實際 DRF 專案的程式碼中,你只會看到:

不過 Person 也算是你 DRF 專案的一部分吧? 只是是在不同資料夾/檔案下,依然是你自己寫的

gbaian10 iT邦新手 5 級 ‧ 2024-09-29 16:54:37 檢舉

因為對照了前一天的文章是有 Post,但沒有 User,沒有寫明完整 import 下有時候不確定哪些是框架內部的,哪些是自己寫的

Kyo Huang iT邦新手 4 級 ‧ 2024-09-29 17:12:42 檢舉

我改完了!ya~

哈哈哈,文章中舉例時,為了精簡,我很常省略 import 部分,想說反正專案程式碼有,除非是第一次 import,又是重要元件,才會留著

不過 Person 也算是你 DRF 專案的一部分吧? 只是是在不同資料夾/檔案下,依然是你自己寫的

是的,這個例子中,Person 是 Django Model,肯定是自己寫的,只是它被 ModelSerializer 轉成序列化器後,序列化器的欄位參數究竟是長怎樣:

  1. 無法直接從程式碼看到(只能透過迂迴的方式,DRF 文件中有教)。
  2. Django Model 的欄位參數,和 DRF 序列化器的欄位參數,很多地方不一樣XD——畢竟這本來就是兩套系統。

所以,這種隱晦,有時很要命

我要留言

立即登入留言